Нам доступен датасет с данными пользователями, которые совершали определенные действия, а также дополнительные датасеты с подробностями. Наша задача - провести оценку результатов А/В тестирования.
Для этого нужно проверить корректность проведения теста, соответствие теста тех.заданию, а также результаты проведенного теста.
Наше техническое задание выглядит следующим образом:
Приступим к выполнению.
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
import datetime as dt
import warnings
warnings.filterwarnings("ignore")
from plotly import graph_objects as go
from scipy import stats as st
import math as mth
from statsmodels.sandbox.stats.multicomp import multipletests
Загрузим данные, ознакомимся с ними и подготовим для дальнейшего исследования.
path = 'D://Irina//datasets//'
participants = pd.read_csv(path + 'final_ab_participants.csv')
events = pd.read_csv(path + 'final_ab_events.csv')
new_users = pd.read_csv(path + 'final_ab_new_users.csv')
marketing_events = pd.read_csv(path + 'ab_project_marketing_events.csv')
#Зададим ограничения на вывод колонок и количество символов - в данном случае "смягчим"
pd.set_option('display.max_columns', None)
pd.options.display.max_colwidth = 100
display(participants, events, new_users, marketing_events)
| user_id | group | ab_test | |
|---|---|---|---|
| 0 | D1ABA3E2887B6A73 | A | recommender_system_test |
| 1 | A7A3664BD6242119 | A | recommender_system_test |
| 2 | DABC14FDDFADD29E | A | recommender_system_test |
| 3 | 04988C5DF189632E | A | recommender_system_test |
| 4 | 482F14783456D21B | B | recommender_system_test |
| ... | ... | ... | ... |
| 18263 | 1D302F8688B91781 | B | interface_eu_test |
| 18264 | 3DE51B726983B657 | A | interface_eu_test |
| 18265 | F501F79D332BE86C | A | interface_eu_test |
| 18266 | 63FBE257B05F2245 | A | interface_eu_test |
| 18267 | 79F9ABFB029CF724 | B | interface_eu_test |
18268 rows × 3 columns
| user_id | event_dt | event_name | details | |
|---|---|---|---|---|
| 0 | E1BDDCE0DAFA2679 | 2020-12-07 20:22:03 | purchase | 99.99 |
| 1 | 7B6452F081F49504 | 2020-12-07 09:22:53 | purchase | 9.99 |
| 2 | 9CD9F34546DF254C | 2020-12-07 12:59:29 | purchase | 4.99 |
| 3 | 96F27A054B191457 | 2020-12-07 04:02:40 | purchase | 4.99 |
| 4 | 1FD7660FDF94CA1F | 2020-12-07 10:15:09 | purchase | 4.99 |
| ... | ... | ... | ... | ... |
| 440312 | 245E85F65C358E08 | 2020-12-30 19:35:55 | login | NaN |
| 440313 | 9385A108F5A0A7A7 | 2020-12-30 10:54:15 | login | NaN |
| 440314 | DB650B7559AC6EAC | 2020-12-30 10:59:09 | login | NaN |
| 440315 | F80C9BDDEA02E53C | 2020-12-30 09:53:39 | login | NaN |
| 440316 | 7AEC61159B672CC5 | 2020-12-30 11:36:13 | login | NaN |
440317 rows × 4 columns
| user_id | first_date | region | device | |
|---|---|---|---|---|
| 0 | D72A72121175D8BE | 2020-12-07 | EU | PC |
| 1 | F1C668619DFE6E65 | 2020-12-07 | N.America | Android |
| 2 | 2E1BF1D4C37EA01F | 2020-12-07 | EU | PC |
| 3 | 50734A22C0C63768 | 2020-12-07 | EU | iPhone |
| 4 | E1BDDCE0DAFA2679 | 2020-12-07 | N.America | iPhone |
| ... | ... | ... | ... | ... |
| 61728 | 1DB53B933257165D | 2020-12-20 | EU | Android |
| 61729 | 538643EB4527ED03 | 2020-12-20 | EU | Mac |
| 61730 | 7ADEE837D5D8CBBD | 2020-12-20 | EU | PC |
| 61731 | 1C7D23927835213F | 2020-12-20 | EU | iPhone |
| 61732 | 8F04273BB2860229 | 2020-12-20 | EU | Android |
61733 rows × 4 columns
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 0 | Christmas&New Year Promo | EU, N.America | 2020-12-25 | 2021-01-03 |
| 1 | St. Valentine's Day Giveaway | EU, CIS, APAC, N.America | 2020-02-14 | 2020-02-16 |
| 2 | St. Patric's Day Promo | EU, N.America | 2020-03-17 | 2020-03-19 |
| 3 | Easter Promo | EU, CIS, APAC, N.America | 2020-04-12 | 2020-04-19 |
| 4 | 4th of July Promo | N.America | 2020-07-04 | 2020-07-11 |
| 5 | Black Friday Ads Campaign | EU, CIS, APAC, N.America | 2020-11-26 | 2020-12-01 |
| 6 | Chinese New Year Promo | APAC | 2020-01-25 | 2020-02-07 |
| 7 | Labor day (May 1st) Ads Campaign | EU, CIS, APAC | 2020-05-01 | 2020-05-03 |
| 8 | International Women's Day Promo | EU, CIS, APAC | 2020-03-08 | 2020-03-10 |
| 9 | Victory Day CIS (May 9th) Event | CIS | 2020-05-09 | 2020-05-11 |
| 10 | CIS New Year Gift Lottery | CIS | 2020-12-30 | 2021-01-07 |
| 11 | Dragon Boat Festival Giveaway | APAC | 2020-06-25 | 2020-07-01 |
| 12 | Single's Day Gift Promo | APAC | 2020-11-11 | 2020-11-12 |
| 13 | Chinese Moon Festival | APAC | 2020-10-01 | 2020-10-07 |
Для анализа мы получили 4 датафрейма. Посмотрим, какая информация в них хранится.
participants - данные об участниках тестов:user_id - идентификатор пользователя;ab_test - название теста;group - группа пользователя.events - все события новых пользователей в период с 7 декабря 2020 по 4 января 2021 года:user_id - идентификатор пользователя;event_dt - дата и время события;event_name - тип события;details - дополнительные данные для событий. Например, для покупок purchase, в этом поле хранится стоимость покупки в долларах.new_users - все пользователи, зарегистрировавшиеся в интернет-магазине в период с 7 по 21 декабря 2020 года:user_id - идентификатор пользователя;first_date - дата регистрации;region - регион пользователя;device - устройство, с которого происходила регистрация.marketing_events - календарь маркетинговых событий на 2020 год:name - название маркетингового события;regions - регионы, в которых будет проводиться рекламная кампания;start_dt - дата начала кампании;finish_dt - дата завершения кампании.Теперь рассмотрим какого типа данные хранятся в наших датафреймах
print(participants.info())
print('='*50)
print(events.info())
print('='*50)
print(new_users.info())
print('='*50)
print(marketing_events.info())
<class 'pandas.core.frame.DataFrame'> RangeIndex: 18268 entries, 0 to 18267 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 18268 non-null object 1 group 18268 non-null object 2 ab_test 18268 non-null object dtypes: object(3) memory usage: 428.3+ KB None ================================================== <class 'pandas.core.frame.DataFrame'> RangeIndex: 440317 entries, 0 to 440316 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 440317 non-null object 1 event_dt 440317 non-null object 2 event_name 440317 non-null object 3 details 62740 non-null float64 dtypes: float64(1), object(3) memory usage: 13.4+ MB None ================================================== <class 'pandas.core.frame.DataFrame'> RangeIndex: 61733 entries, 0 to 61732 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 61733 non-null object 1 first_date 61733 non-null object 2 region 61733 non-null object 3 device 61733 non-null object dtypes: object(4) memory usage: 1.9+ MB None ================================================== <class 'pandas.core.frame.DataFrame'> RangeIndex: 14 entries, 0 to 13 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 14 non-null object 1 regions 14 non-null object 2 start_dt 14 non-null object 3 finish_dt 14 non-null object dtypes: object(4) memory usage: 580.0+ bytes None
participants данные во всех колонках относятся к строчному типу, что соответствует выводимым данным. В 18268 строках не отмечается пропусков.events неверный тип данных у колонки event_dt - строчный вместо даты. Так же отмечается наличие пропусков в колонке details - возможно там есть детали только для некоторых типов событий. Данные в этой колонке имеют тип значений с плавающей точкой.new_users также есть некорректный тип данный у колонки с датами. Пропусков не отмечается.marketing_events тоже проблема с типом данных для дат. Пропусков не отмечается.Требуется провести следующую предобработку:
events, есть ли возможность что-то с ними сделатьПриступим к предобработке
Теперь приступим к выполнению пунктов предобработки, которые мы определили в предыдущем шаге.
display('Таблица participants',
participants.isna().sum().to_frame(),
'Таблица events',
events.isna().sum().to_frame(),
'Таблица new_users',
new_users.isna().sum().to_frame(),
'Таблица marketing_events',
marketing_events.isna().sum().to_frame())
'Таблица participants'
| 0 | |
|---|---|
| user_id | 0 |
| group | 0 |
| ab_test | 0 |
'Таблица events'
| 0 | |
|---|---|
| user_id | 0 |
| event_dt | 0 |
| event_name | 0 |
| details | 377577 |
'Таблица new_users'
| 0 | |
|---|---|
| user_id | 0 |
| first_date | 0 |
| region | 0 |
| device | 0 |
'Таблица marketing_events'
| 0 | |
|---|---|
| name | 0 |
| regions | 0 |
| start_dt | 0 |
| finish_dt | 0 |
Пропуски есть только в таблице new_users в колонке details. Возможно это связано с тем, что не для всех совершаемых событий есть какие-либо детали. Проверим эту теорию.
events.groupby('event_name')['details'].count().to_frame()
| details | |
|---|---|
| event_name | |
| login | 0 |
| product_cart | 0 |
| product_page | 0 |
| purchase | 62740 |
Да, пропусков нет только у действия purchase, следовательно, это не аномалия и с этими пропусками не стоит ничего делать.
Теперь проведём проверку на наличие дубликатов в датасетах. Для начала проверим полные явные дубликаты.
print('Количество явных дубликатов в таблице participants:',
participants.duplicated().sum(),
'\nКоличество явных дубликатов в таблице events:',
events.duplicated().sum(),
'\nКоличество явных дубликатов в таблице new_users:',
new_users.duplicated().sum(),
'\nКоличество явных дубликатов в таблице marketing_events:',
marketing_events.duplicated().sum())
Количество явных дубликатов в таблице participants: 0 Количество явных дубликатов в таблице events: 0 Количество явных дубликатов в таблице new_users: 0 Количество явных дубликатов в таблице marketing_events: 0
Полных дубликатов нет. Проверим, есть ли неполные дубликаты в таблице patricipants, что позволит нам сразу иметь представление о наличии пересечений пользователей между тестами и группами.
print('Пользователей, участвовавших более чем в 1 тесте или более чем в 1 группе:',
participants['user_id'].duplicated().sum())
Пользователей, участвовавших более чем в 1 тесте или более чем в 1 группе: 1602
Почти 9 процентов пользователей участвуют в двух тестах и/или группах, чуть позже мы с ними разберемся. Посмотрим есть ли задвоение пользователей в разрезе стран/устройств.
print('Пользователей, заходивших из разных стран, или с разных устройств:',
new_users['user_id'].duplicated().sum())
Пользователей, заходивших из разных стран, или с разных устройств: 0
Здесь нет повторяющихся значений user_id, теперь перейдем к проверки наличия неявных дубликатов : посмотрим, есть ли ошибки и нестыковки во всех категориальных данных.
print('Уникальные тесты:', participants['ab_test'].unique(),
'\nУникальные действия:', events['event_name'].unique(),
'\nУникальные регионы:', new_users['region'].unique(),
'\nУникальные устройства:', new_users['device'].unique())
Уникальные тесты: ['recommender_system_test' 'interface_eu_test'] Уникальные действия: ['purchase' 'product_cart' 'product_page' 'login'] Уникальные регионы: ['EU' 'N.America' 'APAC' 'CIS'] Уникальные устройства: ['PC' 'Android' 'iPhone' 'Mac']
Категориальные данные также не повторяются и не имеют разночтений. Теперь приступим к изменению типов данных.
events['event_dt'] = pd.to_datetime(events['event_dt'])
new_users['first_date'] = pd.to_datetime(new_users['first_date'])
marketing_events['start_dt'] = pd.to_datetime(marketing_events['start_dt'])
marketing_events['finish_dt'] = pd.to_datetime(marketing_events['finish_dt'])
events.dtypes, new_users.dtypes, marketing_events.dtypes
(user_id object event_dt datetime64[ns] event_name object details float64 dtype: object, user_id object first_date datetime64[ns] region object device object dtype: object, name object regions object start_dt datetime64[ns] finish_dt datetime64[ns] dtype: object)
Все типы данных успешно заменены.
Теперь, когда мы провели предобработку, оценим, насколько корректно происходил набор групп и тестирование.
# Проверим пользователей, которые могли участвовать в двух или нескольких тестах одновременно:
cross_test_users = (participants.groupby('user_id').agg({'ab_test':'nunique'})
.query('ab_test > 1').reset_index())
conflict_test_list = cross_test_users['user_id'].to_list()
len(conflict_test_list)
1602
Итак, как мы и обнаружили ранее - есть пользователи, которые встречаются в разных тестах. Избавимся от всех повторов и получим только тех пользователей, которые участвуют в интересующем нас тесте.
filtered_participants = participants.query('user_id not in @conflict_test_list')
test_participants = filtered_participants.query('ab_test == "recommender_system_test"')
print('Минимальная дата регистрации новых пользователей:', new_users['first_date'].min(),
'\nМаксимальная дата регистрации новых пользователей:', new_users['first_date'].max())
Минимальная дата регистрации новых пользователей: 2020-12-07 00:00:00 Максимальная дата регистрации новых пользователей: 2020-12-23 00:00:00
Первая дата регистрации совпадает с заявленными в техническом задании. А вот последняя дата выбивается. Отфильтруем пользователей, чтобы данные соответствовали заданию.
filtered_new_users = new_users.query('first_date < "2020-12-22"')
print('Максимальная дата регистрации новых пользователей:',
filtered_new_users['first_date'].max())
Максимальная дата регистрации новых пользователей: 2020-12-21 00:00:00
Теперь проверим когда пользователи совершали события.
print('Дата первого события:', events['event_dt'].min(),
'\nДата последнего события:', events['event_dt'].max())
Дата первого события: 2020-12-07 00:00:33 Дата последнего события: 2020-12-30 23:36:33
Согласно нашим данным, тестирование проводилось на 5 дней меньше. Таким образом, не все пользователи имели возможность участвовать в эксперименте установленные 14 дней с момента регистрации. Это может привести к некоторому смещению результатов. С этим мы, увы, ничего сделать не можем.
Теперь получим датасет с новыми пользователями, которые участвуют в нашем тесте, и заодно, проверим нет ли пересечения пользователей между группами.
target_test = filtered_new_users.merge(test_participants, on='user_id')
#Проверим, есть ли повторяющиеся пользователи в разных группах
target_test.groupby('user_id').agg({'group':'nunique'}).query('group > 1')
| group | |
|---|---|
| user_id |
Пересечений пользователей нет, теперь получим события, которые совершали все пользователи.
target_test = target_test.merge(events, on='user_id', how='left')
target_test.sample(20)
| user_id | first_date | region | device | group | ab_test | event_dt | event_name | details | |
|---|---|---|---|---|---|---|---|---|---|
| 17586 | FD29640EA6992980 | 2020-12-19 | EU | PC | A | recommender_system_test | 2020-12-19 19:31:53 | product_page | NaN |
| 11314 | E8173771F9C6C251 | 2020-12-16 | EU | PC | A | recommender_system_test | 2020-12-16 22:36:05 | login | NaN |
| 2231 | B0B3A724D085010F | 2020-12-14 | EU | Mac | A | recommender_system_test | 2020-12-17 07:11:21 | login | NaN |
| 15511 | 9EA3340B70A596AE | 2020-12-18 | EU | Android | A | recommender_system_test | 2020-12-27 04:14:07 | login | NaN |
| 608 | 7E8720DB6A21CF66 | 2020-12-07 | EU | Android | B | recommender_system_test | 2020-12-09 20:44:57 | product_cart | NaN |
| 15995 | 20637F29BB029BEA | 2020-12-18 | EU | Android | A | recommender_system_test | 2020-12-18 06:36:49 | login | NaN |
| 12091 | 4266741E592070B6 | 2020-12-16 | EU | iPhone | A | recommender_system_test | 2020-12-16 07:05:00 | login | NaN |
| 8201 | 92EFA54F7198BC87 | 2020-12-08 | EU | Android | B | recommender_system_test | 2020-12-11 22:15:37 | login | NaN |
| 8882 | CBBD6A13EEE8DE81 | 2020-12-15 | EU | PC | B | recommender_system_test | NaT | NaN | NaN |
| 17727 | A9DABAEA2233576C | 2020-12-19 | EU | Android | A | recommender_system_test | 2020-12-22 01:03:08 | login | NaN |
| 866 | 6CAC7EB3574A1F02 | 2020-12-07 | EU | PC | A | recommender_system_test | NaT | NaN | NaN |
| 16234 | F9D53F0BA957F728 | 2020-12-18 | EU | iPhone | A | recommender_system_test | 2020-12-21 20:59:03 | product_cart | NaN |
| 5753 | 4AEC1F638C2F1FC8 | 2020-12-21 | EU | PC | A | recommender_system_test | 2020-12-29 21:29:15 | product_page | NaN |
| 11869 | 1F77C3195B36A98B | 2020-12-16 | EU | Android | A | recommender_system_test | 2020-12-17 19:38:36 | login | NaN |
| 11114 | 89EF9F0C1676188A | 2020-12-16 | EU | Android | B | recommender_system_test | 2020-12-18 16:42:17 | login | NaN |
| 5 | 831887FE7F2D6CBA | 2020-12-07 | EU | Android | A | recommender_system_test | 2020-12-08 10:52:27 | product_cart | NaN |
| 21058 | 5D7099BA15597D3D | 2020-12-20 | EU | Android | A | recommender_system_test | 2020-12-21 08:14:48 | product_page | NaN |
| 15279 | 7347C03E6A300EFD | 2020-12-18 | EU | Android | A | recommender_system_test | 2020-12-25 02:45:27 | purchase | 4.99 |
| 16958 | 6E6DC58015E1CBA0 | 2020-12-12 | EU | PC | A | recommender_system_test | NaT | NaN | NaN |
| 19804 | A354B804BD270C75 | 2020-12-20 | EU | iPhone | A | recommender_system_test | 2020-12-24 19:36:30 | purchase | 4.99 |
Для удобства дальнейшей работы создадим столбец, содержащий только дату совершаемого события, без времени.
target_test.loc[:,'date'] = target_test.event_dt.apply(lambda x: x.date())
target_test
| user_id | first_date | region | device | group | ab_test | event_dt | event_name | details | date | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | D72A72121175D8BE | 2020-12-07 | EU | PC | A | recommender_system_test | 2020-12-07 21:52:10 | product_page | NaN | 2020-12-07 |
| 1 | D72A72121175D8BE | 2020-12-07 | EU | PC | A | recommender_system_test | 2020-12-07 21:52:07 | login | NaN | 2020-12-07 |
| 2 | 831887FE7F2D6CBA | 2020-12-07 | EU | Android | A | recommender_system_test | 2020-12-07 06:50:29 | purchase | 4.99 | 2020-12-07 |
| 3 | 831887FE7F2D6CBA | 2020-12-07 | EU | Android | A | recommender_system_test | 2020-12-09 02:19:17 | purchase | 99.99 | 2020-12-09 |
| 4 | 831887FE7F2D6CBA | 2020-12-07 | EU | Android | A | recommender_system_test | 2020-12-07 06:50:30 | product_cart | NaN | 2020-12-07 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 21110 | 0416B34D35C8C8B8 | 2020-12-20 | EU | Android | A | recommender_system_test | 2020-12-24 09:12:51 | product_page | NaN | 2020-12-24 |
| 21111 | 0416B34D35C8C8B8 | 2020-12-20 | EU | Android | A | recommender_system_test | 2020-12-20 20:58:25 | login | NaN | 2020-12-20 |
| 21112 | 0416B34D35C8C8B8 | 2020-12-20 | EU | Android | A | recommender_system_test | 2020-12-21 22:28:29 | login | NaN | 2020-12-21 |
| 21113 | 0416B34D35C8C8B8 | 2020-12-20 | EU | Android | A | recommender_system_test | 2020-12-24 09:12:49 | login | NaN | 2020-12-24 |
| 21114 | 89CB0BFBC3F35126 | 2020-12-20 | EU | PC | B | recommender_system_test | NaT | NaN | NaN | NaT |
21115 rows × 10 columns
По выведенным данным можно увидеть, что есть пользователи, которые не совершали действий.
Теперь посмотрим, соответствует ли количество новых пользователей из региона EU поставленному тех.заданию.
region_data = filtered_new_users.groupby('region').agg({'user_id':'nunique'})
region_data = region_data.merge(target_test.pivot_table(index='region',
values='user_id',
aggfunc = 'nunique'), on='region')
region_data.columns = ['all_users', 'test_users']
region_data['share'] = round(region_data['test_users']/region_data['all_users']*100)
region_data
| all_users | test_users | share | |
|---|---|---|---|
| region | |||
| APAC | 2883 | 72 | 2.0 |
| CIS | 2900 | 55 | 2.0 |
| EU | 42340 | 4749 | 11.0 |
| N.America | 8347 | 223 | 3.0 |
Набрать необходимый процент пользователей не удалось. Еще одно несоответствие.
plt.figure(figsize=(15, 7))
sns.lineplot(data=target_test.pivot_table(index='first_date',
columns='group',
values='user_id',
aggfunc='count'))
plt.xlabel('Дата регистрации')
plt.ylabel('Количество пользователей')
plt.title('Динамика набора пользователей в группы', fontsize=20, color='SteelBlue')
plt.show()
Пользователи набирались неравномерно. В группе А наблюдается спад в первые дни набора, а затем резкий скачок зарегистрировавшихся пользователей 14 декабря. Группа В также набиралась неравномерно, также заметно сильное различие в количестве пользователей.
target_test.groupby('group')['user_id'].nunique().plot.bar(rot=0,
figsize=(12,6),
xlabel='Группа',
ylabel='Количество пользователей')
plt.title('Распределение пользователей по группам', fontsize=20, color='SteelBlue')
plt.show()
Да, в группах различается количество пользователей, и достаточно сильно. Скорее всего, это повлияет на результаты тестирования.
Оценим, все ли пользователи совершали действия после регистрации.
only_registration = (target_test.groupby(['user_id','group']).agg({'event_name':'nunique'})
.query('event_name < 1').reset_index())
print('Общее число пользователей, участвующих в тесте:', target_test['user_id'].nunique(),
'\nЧисло пользователей, которые не совершали действий:', len(only_registration))
only_registration_list = only_registration['user_id'].to_list()
Общее число пользователей, участвующих в тесте: 5099 Число пользователей, которые не совершали действий: 2311
45% пользователей не совершали никаких действий после регистрации. Очень много, однако, такое тоже может быть. Оценим, равномерно ли распределены пользователи, которые не совершали действий после регистрации.
only_registration.groupby('group')['user_id'].count().plot.bar(color='g',
rot=0,
xlabel='Группа',
ylabel='Количество пользователей',
figsize=(12,6))
plt.title('Распределение пользователей,\n не совершавших действий по группам',
fontsize=20, color='SteelBlue')
plt.show()
Более половины пользователей, которые не совершали события находятся в группе В. В контрольной группе только треть таких пользователей.
Так как мы не сможем корректно оценить изменения конверсии на пользователях, которые не совершали события - отфильтруем наш датафрейм. Это не приведет к серьезному сдвигу, так как нас не интересует изменение конверсии пользователей в авторизацию. А также это повысит точность оценки изменений интересующих нас метрик.
f_target_test = target_test.query('user_id not in @only_registration_list')
f_target_test.groupby('group')['user_id'].nunique().plot.bar(rot=0,
figsize=(12,6),
color = 'orange',
xlabel='Группа',
ylabel='Количество пользователей')
plt.title('Распределение пользователей по группам после фильтрации',
fontsize=20, color='SteelBlue')
plt.show()
Теперь можно заметить более выраженную неравномерность в наборе групп.
Теперь посмотрим, в какой день после регистрации пользователи в среднем начинали совершать действия.
#Добавим разницу между датой регистрации и датой совершения событий
f_target_test['days_since_registration'] = (f_target_test['event_dt'] -
f_target_test['first_date']).dt.days
#Теперь отфильтруем так, чтобы были только те события,
#которые не совершались более чем через 14 дней
lifetime_dataset_filtered = f_target_test[f_target_test['days_since_registration'] < 14]
#Получим распределение совершения событий по дням с момента регистрации в разрезе групп
lifetime_event = lifetime_dataset_filtered.pivot_table(index='days_since_registration',
columns = 'group',
values='user_id',
aggfunc='count')
lifetime_event.plot.bar(rot=0, xlabel='День с момента регистрации',
ylabel='Количество пользователей', figsize=(15,8))
plt.title('Распределение совершения событий пользователями по дням после регистрации',
fontsize=20, color='SteelBlue')
plt.show()
В основном, в обеих группах чаще всего пользователи совершали события в течение первого дня, с момента регистрации (в день регистрации). Затем, к 14 дню все меньше пользователей совершают события.
Теперь посмотрим, когда пользователи совершают впервые каждое событие.
first_event_dates = (lifetime_dataset_filtered
.groupby(['user_id','event_name'])['days_since_registration'].min()
.to_frame().reset_index())
first_dates = first_event_dates.pivot_table(index='event_name',
columns='days_since_registration',
values='user_id',aggfunc='count')
first_dates = first_dates.T
first_dates.plot.bar(stacked=True, figsize=(15,8), rot=0)
plt.xlabel('День с момента регистрации')
plt.ylabel('Количество событий')
plt.title('Время совершения пользователем каждого вида события в первый раз',
fontsize=20, color='SteelBlue')
plt.show()
Все события в первый раз в основном совершаются в первый день - в день регистрации. Некоторые пользователи авторизуются только на второй день, перейти в корзину - на третий. Совершить оплату могут как в первые три дня, так и на 7 день после регистрации.
Проведя изучение данных, вот что можно сказать о соответствии проводимого теста поставленному ТЗ(тех.заданию):
Таким образом, выборка составлена некорректно, возможность получения достоверного результата минимальна.
Теперь проведём исследовательский анализ данных, чтобы сравнить изменения пользовательской активности в разных группах.
not_null_events = f_target_test.query('user_id not in @only_registration_list')
event_per_users = not_null_events.pivot_table(index='user_id',
values=['event_name','group'],
aggfunc={'event_name':'count',
'group':'first'}).reset_index()
plt.figure(figsize=(15,8))
sns.histplot(event_per_users, x='event_name', hue='group', bins=25, kde=True)
plt.xlabel('Среднеее число событий')
plt.ylabel('Количество пользователей')
plt.title('Среднее число событий, совершаемых пользователем', fontsize=20, color='SteelBlue')
plt.show()
В обеих группах каждый пользователь в среднем совершал менее 10 событий. Чаще всего пользователи совершали около 5 событий.
event_group_a = f_target_test.query('group == "A"').pivot_table(index='date',
columns='event_name',
values='user_id',
aggfunc='count')
event_group_a.columns = ['login','product_cart','product_page','purchase']
event_group_b = f_target_test.query('group == "B"').pivot_table(index='date',
columns='event_name',
values='user_id',
aggfunc='count')
event_group_b.columns = ['login','product_cart','product_page','purchase']
fig, axes = plt.subplots(2, figsize=(15, 18))
event_group_a.plot.bar(stacked=True,
ax=axes[0],
xlabel='Дата',
ylabel = 'Количество событий',
title='В группе А')
plt.setp(axes[0].xaxis.get_majorticklabels(), rotation=30 , ha="right")
event_group_b.plot.bar(stacked=True,
ax=axes[1],
xlabel='Дата',
ylabel = 'Количество событий',
title='В группе В')
plt.setp(axes[1].xaxis.get_majorticklabels(), rotation=30 , ha="right")
fig.suptitle('Динамика количества событий в группах по дням в разрезе событий',
fontsize=20, color='SteelBlue')
fig.show()
Посмотрим, могли ли проводимые маркетинговые события каким-либо образом повлиять на поведение пользователей тестируемых групп.
#Выведем все маркетинговые мероприятия, которые начинаются в исследуемые даты
sale_events = (marketing_events
.query('start_dt >="2020-12-07" & start_dt <"2020-12-30"')
.reset_index(drop=True))
sale_events
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 0 | Christmas&New Year Promo | EU, N.America | 2020-12-25 | 2021-01-03 |
У нас есть одно мероприятие, которое накладывается на время проведения теста и может повлиять на нашу целевую аудиторию. Проверим, так ли это.
plt.figure(figsize=(15,5))
plt.title('Влияние маркетинговых событий на активность пользователей',
fontsize=20, color='SteelBlue')
ax = sns.lineplot(data=f_target_test.pivot_table(index='date',
columns='group',
values='event_name',
aggfunc='count'))
ax.set_xlabel('Дата')
ax.set_ylabel('Количество событий')
#Выведем цветные прямоугольники на график для визуализации маркетинговых мероприятий
#Получим палетку цветов, чтобы отличать события
colors = sns.color_palette('Accent')
#Автоматизируем визуализацию
for index, row in sale_events.iterrows():
color = colors[index % len(colors)]
ax.axvspan(xmin=row['start_dt'],
xmax=row['finish_dt'],
alpha=0.3, label=row['name'], color = color)
#Выведем легенду, которая принимает цвет события и группы,
#и сдвинем, чтобы она не мешала в случае появления новых мероприятий
plt.legend(loc='best',bbox_to_anchor = (1,1))
plt.show()
Судя по графику, в обеих группах наблюдается спад пользовательской активности после 21 декабря. Проведение маркетингового события мало отразилось на пользователях обеих групп, поэтому не будем избавляться от этих данных.
Теперь перейдем к самому важному - узнаем, получилось ли достичь указанного увеличения конверсии в тестовой группе. У нас есть последовательные действия, которые могут сделать пользователи, которые складываются в следующую воронку:
login - авторизоваться в приложении,product_page - перейти на страницу товара,product_cart - перейти в корзину,purchase - совершить покупку.Проверим увеличение конверсии в группе В по следующим действиям : product_page,product_cart,purchase.
test_funnel = f_target_test.pivot_table(index='group',
columns = 'event_name',
values = 'user_id',
aggfunc = 'nunique').T
test_funnel.columns = ['A','B']
test_funnel = test_funnel.sort_values(by='A',ascending=False).reset_index()
test_funnel = test_funnel.reindex([0,1,3,2])
test_funnel
| event_name | A | B | |
|---|---|---|---|
| 0 | login | 2082 | 706 |
| 1 | product_page | 1360 | 397 |
| 3 | product_cart | 631 | 195 |
| 2 | purchase | 652 | 198 |
fig = go.Figure()
fig.add_trace(go.Funnel(
name = 'Группа А',
y = test_funnel['event_name'],
x = test_funnel['A'],
textinfo = "value+percent previous"))
fig.add_trace(go.Funnel(
name = 'Группа В',
orientation = "h",
y = test_funnel['event_name'],
x = test_funnel['B'],
textposition = "inside",
textinfo = "value+percent previous"))
fig.update_layout(
title_text='Воронка по группам теста',
width = 1000,
height = 500
)
fig.show()
Нет, достичь увеличения конверсии не удалось, наоборот, отмечается снижение. Также, можно отметить, что не все пользователи выполняли каждое действие из указанной воронки - некоторые пользователи пропускают посещение корзины и оплачивают товары сразу.
На основании исследовательского анализа мы можем сделать следующие выводы:
Настало время оценить результаты А/В тестирования и сделать вывод о проведенном тесте.
Сравним конверсию по основным метрикам для обеих групп и оценим статистическую значимость различий, если таковые имеются.
Для сравнения выведем следующие гипотезы: Для всех тестирований ниже примем следующие гипотезы:
event_by_groups = f_target_test.pivot_table(index='event_name',
values='user_id',
columns='group',
aggfunc='nunique')
event_by_groups = event_by_groups.T
event_by_groups['total_users'] = f_target_test.groupby('group').agg({'user_id':'nunique'})
event_by_groups.columns = ['login',
'product_cart',
'product_page',
'purchase','total_users']
event_by_groups = event_by_groups.reset_index()
event_by_groups
| group | login | product_cart | product_page | purchase | total_users | |
|---|---|---|---|---|---|---|
| 0 | A | 2082 | 631 | 1360 | 652 | 2082 |
| 1 | B | 706 | 195 | 397 | 198 | 706 |
#Функция, которая проводит z-тестирование
def z_test(hits, trials, alpha):
# доля успехов в исследуемых группах:
p1 = hits[0]/trials[0]
p2 = hits[1]/trials[1]
# доля успехов в комбинированном датасете:
p_combined = (hits[0] + hits[1]) / (trials[0] + trials[1])
# разница долей в датасетах
difference = p1 - p2
# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', p_value)
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными')
#вернем значение p_value для поправок на множественное тестирование
return p_value
#Создадим функцию, которая будет проводить z-тест для всех параметров по группам
#На вход будет принимать номера экспериментов и значение статистической значимости
def lazy_check (n_1, n_2, alpha):
#Зададим переменные, которые будут принимать значения p_value для поправок на множественное
#проведение гипотез
pv1 = 0
pv2 = 0
pv3 = 0
#Зададим список значений trials для обоих экспериментов
#(общее количество пользователей каждого эксперимента)
n_trials = ([event_by_groups['total_users'][n_1],
event_by_groups['total_users'][n_2]])
#Теперь получим списки значений hits по каждой исследуемой выборке
product_page = ([event_by_groups['product_page'][n_1],
event_by_groups['product_page'][n_2]])
product_cart = ([event_by_groups['product_cart'][n_1],
event_by_groups['product_cart'][n_2]])
purchase = ([event_by_groups['purchase'][n_1],
event_by_groups['purchase'][n_2]])
#Автоматизируем вывод результатов тестирования
print('')
print('Сравнение долей по пользователям, открывшим страницу товара:')
pv1 = z_test(product_page, n_trials, alpha)
print('')
print('Сравнение долей по пользователям, перешедшим в корзину:')
pv1 = z_test(product_cart, n_trials, alpha)
print('')
print('Сравнение долей по пользователям, совершившим покупку:')
pv3 = z_test(purchase, n_trials, alpha)
#Вернем список p_value
return [pv1, pv2, pv3]
Для начала проведём исследование, при вероятности ложноположительного результата в 5%.
pv_05 = 0
pv_05 = lazy_check(0,1, 0.05)
Сравнение долей по пользователям, открывшим страницу товара: p-значение: 1.5371909704686715e-05 Отвергаем нулевую гипотезу: между долями есть значимая разница Сравнение долей по пользователям, перешедшим в корзину: p-значение: 0.1766337419130104 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Сравнение долей по пользователям, совершившим покупку: p-значение: 0.10281767567786759 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Самым критическим оказалась разница в конверсии в открытие страницы товара. Похоже, что рекомендации несколько отталкивают пользователей. Однако, это никак не повлияло на добавление товаров в корзину и покупку товаров.
Теперь оценим разницу, при вероятности ложноположительного результата в 10%.
pv_10 = 0
pv_10 = lazy_check(0,1,0.1)
Сравнение долей по пользователям, открывшим страницу товара: p-значение: 1.5371909704686715e-05 Отвергаем нулевую гипотезу: между долями есть значимая разница Сравнение долей по пользователям, перешедшим в корзину: p-значение: 0.1766337419130104 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Сравнение долей по пользователям, совершившим покупку: p-значение: 0.10281767567786759 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
При увеличении вероятности ложноположительного результата также есть разница только в конверсии в просмотр страницы продукта. Остальные метрики между группами не различаются.
Так как мы сравниваем доли по нескольким изменениям (в нашем случае доли пользователей, которые побывали на конкретном этапе), стоит учесть поправки на множественное тестирование. Таким образом, мы сможем избежать возникновения ошибок и скорректировать уровень статистической значимости. Для этого мы проверим наши проведенные тесты на два типа ошибок:
FWER (Family-Wise Error Rate) - Групповая вероятность ошибки, которая представляет собой вероятность получить по крайней мере одну ошибку первого рода FDR (False Discovery Rate) — это среднее значение отношения ошибок первого рода к общему количеству отклонений основной гипотезы
lpv_05 = [pv_05]
lpv_10 = [pv_10]
#Вероятность получить хотя бы одну ошибку первого рода для двух значений alpha
print("FWER: " + str(multipletests(sorted(pv_05), alpha=0.05,
method='holm', is_sorted = True)))
print("FWER: " + str(multipletests(sorted(pv_10), alpha=0.1,
method='holm', is_sorted = True)))
print('')
#Среднее значение отношения ошибок первого рода к общему количеству отклонений основной гипотезы
print("FDR: " + str(multipletests(pv_05, alpha=0.05,
method='fdr_bh', is_sorted = False)))
print("FDR: " + str(multipletests(pv_10, alpha=0.1,
method='fdr_bh', is_sorted = False)))
FWER: (array([ True, False, False]), array([0. , 0.20563535, 0.20563535]), 0.016952427508441503, 0.016666666666666666) FWER: (array([ True, False, False]), array([0. , 0.20563535, 0.20563535]), 0.03451061539437028, 0.03333333333333333) FDR: (array([False, True, False]), array([0.17663374, 0. , 0.15422651]), 0.016952427508441503, 0.016666666666666666) FDR: (array([False, True, False]), array([0.17663374, 0. , 0.15422651]), 0.03451061539437028, 0.03333333333333333)
Использованный метод выводит результат проведения теста в виде буллевых значений, где:
Затем выводится список скорректированных значений p_value для каждого проведенного теста, а также скорректированные значения для уровня статистической значимости по двум методам поправок - Сидака и Бонферрони.
Исходя из полученных нами данных, при снижении вероятности возникновения ложноположительного результата до 3% разница в переходе на страницу товара пользователями из двух групп теряет статистическую значимость, а вот разница перехода в корзину наоборот становится статистически значимой. Разницы между группами в совершении покупок все так же не наблюдается.
Однако, тест не показал значимых ухудшений.
Мы провели большую работу по оценке результатов проведенного А/В тестирования и вот какие выводы мы можем сделать:
Обнаружено несоответствие поставленному ТЗ:
Об изменениях пользовательской активности:
О результатах теста:
Какие рекомендации можно дать: